返回列表

PraisonAI: DNS rebinding bypass in `web_crawl` SSRF protection allows internal response disclosure

CVE-2026-61430RCE2026-10-08

漏洞描述

## Summary PraisonAI's `web_crawl` agent tool performs a server-side HTTP fetch of an agent/attacker-influenced URL. SSRF is meant to be prevented by `_is_safe_crawl_url()`, which resolves the hostname and rejects private/loopback/link-local IPs **at validation time**. The validated value is the URL *string* (not a pinned IP); the fetch backend then **re-resolves the hostname at connection time**. Because validation and connection perform two independent DNS resolutions, a **DNS-rebinding** domain that returns a public IP during validation and an internal IP during the fetch fully bypasses the guard, and the internal HTTP response body is returned to the caller. This is **SSRF with internal response disclosure (read-back)** — not blind SSRF. Runtime-confirmed against PraisonAI 4.6.63; the crawl response returned the controlled internal markers `PRAISONAI_INTERNAL_SECRET_CANARY_7f3a91` / `FAKE_INTERNAL_TOKEN_DO_NOT_USE_7f3a91`. Severity High. Reachable by any actor who can influence the URL an agent crawls (e.g. a chat/bot/agent surface). ## Details ### Affected component - Package: `praisonaiagents` (PraisonAI), version **4.6.63**. - File: `src/praisonai-agents/praisonaiagents/tools/web_crawl_tools.py`; tool `web_crawl` / `crawl_web` (part of the default bot tool set). ### Vulnerable code / root cause **Code point 1 — check-time-only DNS validation, no IP pinning** Path: `src/praisonai-agents/praisonaiagents/tools/web_crawl_tools.py` Function: `_is_safe_crawl_url` Snippet: ```python for info in socket.getaddrinfo(hostname, None): # resolve at CHECK time ip = ipaddress.ip_address(info[4][0]) if (ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_unspecified): return False return True ``` Issue: the guard validates the hostname by resolving it **once at check time**. It does not pin the resolved IP and does not return/forward that IP to the HTTP client. Any later resolution can differ. **Code point 2 — guard runs, then the URL *string* is handed to the backend** Function: `web_crawl` Snippet: ```python for u in raw_url_list: if _is_safe_crawl_url(u): # validate the URL string url_list.append(u) ... results = _crawl_with_httpx(url_list) # or _crawl_with_crawl4ai(url_list) ``` Issue: attacker-controlled input (`urls`) is validated as a string; the backend then fetches that string and **re-resolves DNS independently** of the guard. There is no shared, pinned IP between check and fetch. **Code point 3 — `_crawl_with_httpx` backend re-resolves (redirect re-validation does not stop rebinding)** Function: `_crawl_with_httpx` Snippet: ```python with httpx.Client(follow_redirects=False, timeout=30.0) as client: for _ in range(max_redirects + 1): if not _is_safe_crawl_url(current): # re-resolves hostname (CHECK) raise ValueError("Redirect target failed SSRF validation") response = client.get(current) # resolves AGAIN at CONNECT ``` Issue: even with per-hop redirect re-validation, `_is_safe_crawl_url(current)` and `client.get(current)` are **two separate DNS resolutions** of the same hostname. A rebinding domain answers public to the check and internal to the connect → TOCTOU bypass. No IP pinning. **Code point 4 — urllib fallback (same function), no per-hop guard** Snippet: ```python import urllib.request with urllib.request.urlopen(url, timeout=30) as response: # re-resolves + auto-follows redirects content = response.read().decode('utf-8', errors='ignore') ``` Issue: when `httpx` is not installed, this fallback inside `_crawl_with_httpx` fetches the URL and auto-follows redirects with no per-hop/per-connect validation. (Results from this function are labelled `"provider": "httpx"` regardless of which path runs.) **Code point 5 — crawl4ai/Chromium backend (confirmed addendum)** The crawl4ai backend (`_crawl_with_crawl4ai` → `crawler.arun(url=url)`, headless Chromium) is also runtime-confirmed affected (browser re-resolves DNS / follows redirects with no per-connect guard). To keep this report focused on the `web_crawl` SSRF guard, the backend-specific evidence is in `SSRF-04_Crawl4AI_SSRF_Backend_Addendum.md`. ### Attack flow 1. Attacker controls a hostname (e.g. `rebind.lab`) whose authoritative DNS rebinds. 2. Lookup #1 (the guard) → a public IP → `_is_safe_crawl_url()` returns true. 3. The backend re-resolves → the attacker's DNS now answers an internal/private IP (cloud metadata, loopback, internal service). 4. The backend connects to the internal service and returns its body to the caller → internal data disclosure. ### Why existing protection is bypassed - The guard validates the hostname, not a pinned IP; check and connect resolve independently → DNS rebinding (TOCTOU) defeats it on every backend. - Redirect re-validation (httpx path) re-checks the *hostname* but still re-resolves at connect, so it does not stop rebinding; the urllib fallback and crawl4ai backends have no per-hop guard at all. ### Security boundary The server-side fetch reaches internal/loopback/metadata services not exposed to the attacker and returns their content (CVSS Scope: Changed). Reachable wherever an agent can be induced to crawl an attacker-supplied URL (PR:L). An unauthenticated single-request path to `web_crawl` read-back was not found in 4.6.63 (so PR:N / Critical is not claimed). ## Proof of Concept ### Environment Real PraisonAI 4.6.63 in a local Docker runtime; a controlled internal canary service (Docker-internal only, not published) returns synthetic markers; a controlled DNS responder implements rebinding for `rebind.lab`. No public host / real metadata / real secret. Runnable assets: `PraisonAI-Runtime-Repro\runtime-files\`. ### Steps to reproduce 1. Burp Repeater tab `PRAI-05-01-DNS-Rebind-Trigger` → `127.0.0.1:18080`: ```http POST /tool/web_crawl HTTP/1.1 Host: 127.0.0.1:18080 Content-Type: application/json {"url":"http://rebind.lab:8081/secret"} ``` 2. Send (`PRAI-05-02-DNS-Rebind-Secret-Readback` captures the response). If a send returns the "blocked" error, the rebinding DNS auto-resets (~3s) — resend. 3. Redirect variant: `PRAI-05-03-Redirect-Trigger` / `PRAI-05-04-Redirect-Secret-Readback` send `{"url":"http://redirector:8082/redirect-to-internal"}`. ### Expected result A safe SSRF guard refuses destinations that resolve to internal/private IPs regardless of DNS timing or redirects, and does not return internal content. ### Actual result HTTP 200 with the internal body in the crawl result. Primary evidence is the `provider: "httpx"` backend returning the internal canary via DNS rebinding: ```json {"input_url":"http://rebind.lab:8081/secret", "result":{"content":"{ ... \"secret\": \"PRAISONAI_INTERNAL_SECRET_CANARY_7f3a91\", \"token\": \"FAKE_INTERNAL_TOKEN_DO_NOT_USE_7f3a91\" ... }","provider":"httpx"}} ``` The redirect variant returns the same internal markers via a redirect chain (`provider: "httpx"`). ### Screenshots **DNS rebinding read-back** The attacker-controlled `rebind.lab` URL is accepted by `web_crawl`, and the PraisonAI response contains the internal canary response body. <img width="1543" height="785" alt="01-DNS-Rebind-Burp-Readback" src="https://github.com/user-attachments/assets/e86e95dd-3d3a-4bef-b1c6-cb897234a212" /> **DNS rebinding runtime evidence** The runtime log shows `rebind.lab` first resolving to an allowed/public IP during validation (`guard-pass`), then resolving to an internal Docker IP during the actual fetch (`fetch-hit`). The internal canary receives `GET /secret` from the PraisonAI container. <img width="1654" height="828" alt="02-DNS-Rebind-DNS-Log-And-Internal-Hit" src="https://github.com/user-attachments/assets/f1d28046-de34-48f0-9bee-bbe26e598d5f" /> **Redirect-based SSRF read-back** The attacker-controlled redirector URL is accepted by `web_crawl`. PraisonAI follows the redirect and returns the internal canary response body containing `PRAISONAI_INTERN

查看原文